002.claude code 源码解析

一、设计理念与架构总览

为什么 claude-code 是一个 React 应用?

问题 1:流式输出的刷新

传统方式:

React 方式:

const [text, setText] = useState('')
return <Text>{text}</Text>
// 每次 token 到来:setText (prev => prev + token)

React 的虚拟 DOM diff 自动计算什么变了,只更新变化的部分。

问题2 &3:权限确认+工具进度

权限确认弹窗:
传统CLI:暂停当前输出-显示确认框,等用户输入y/n-恢复之前的状态
React:

{showDialog && <PermissionDialog />}

用组件树的插入/删除控制显示隐藏

工具执行进度:
BashTool执行时要显示"已执行3.2s,输出142行...”输出结果要可以折叠
-> 这些都是组件状态,声明式管理起来非常自然

整体分层架构

![[cc整体架构.excalidraw|800]]

关键交界点

这三个函数就是整个系统的主动脉,理解他们就理解了整个数据流

一条消息从用户按下 Enter 到 Claude 响应的完整旅程

![[一条消息的完整旅程.excalidraw|600]]

整个过程中,用户看到的是实时流式输出,不是等全部完成后再一次性显示

核心:事件驱动 + 异步生成器

关键点:整个流程是事件驱动 + 异步生成器
没有回调地狱,也没有 EventEmitter 传递状态

// 调用方用 for await...of 消费
for await(const event of query(prompt)) {
	if(event.type ==='tool_use') {
		await executeTool(event.tool)
	}
}

关键目录结构

src/
|-- main.tsx 			# cli 入口
|-- query.ts 			# 查询循环核心
|-- QueryEngine.ts		# SDK模式oo包装
|-- Tool.ts 			# 工具接口类型
|
|-- tools/ 				# 60+工具实现
|	|-- BashTool/		# bash命令执行
|	|-- AgentTool/		# 子Agent生成
|	|-- FileEditTool/ 	# 文件精准编辑
|	|-- MCPTool/ 		# MCP工具包装
|	
|-- hooks/				# 70+React hooks
|-- state/ 				# 不可变AppState Store

技术栈

为什么自研 Ink 引擎?

src/ink/ 有完整的布局引|擎(基于Yoga CSS Flexbox)

官方 ink 库不支持:

在长对话里,这些限制会导致明显的性能问题

自研 = 完全控制渲染管道

为什么用异步生成器 ?

EventEmitter 的问题:

异步生成器的优势:

for await (const event of query (prompt)) {
	// 处理事件
}
循环结束= 任务结束

return 天然表示“我完成了"代码逻辑更线性

为什么用 Bun feature() 做特性门控而不是环境变量?

claude-code 有多个构建变体:开源版、企业版、内部版、Ant 内部工具

环境变量(运行时):

if(process.env.FEATURE X) // 这行代码仍然打包

Bun feature()(构建时):

feature('FLAG'). // 未启用则整个 if 块不打包

OSS 用户下载的 bundle 里完全没有企业内部功能的代码


二、启动流程与性能工程

并行 I/O 启动:把最慢的操作放到最前面

bad case(串行):

时间 0 ms:开始 import 模块
时间 500 ms:import 完成
时间 501 ms:开始读取 Keychain(200 ms)
时间 701 ms:读取完成 开始初始化
时间 800 ms:用户可以输入

claude-code 的写法(并行)

时间 0ms:开始 import 模块 + 同时触发 Keychain 读取(200 ms)
时间 200 ms:Keychain 读取完成(在后台)
时间 500 ms:import 完成
时间 501 ms:调用 getApiKey() -> 结果已经在内存里!
时间 600 ms:用户可以输入

节省了 200 ms。

import 过程本身需要时间,CPU 是在执行 JS 的,但 I/O 可以并行进行,把重量级 I/O 操作提前触发,让他们在 CPU 忙着加载模块时在后台完成 --- 这就是并行化 I/O 等待的经典模式。

Phase 0:并行触发重量级 IO

// 第9行:性能打点
profileCheckpoint ('main tsx_entry')
// 第 10-11 行:并行发起两个重量级异步操作
startMdmRawRead () // 读取 MDM 配置
startKeychainPrefetch () // 预取系统 Keychain 里的 API Key

为什么在最开始?

操作 耗时 说明
startMdmRawRead() ~100-200ms 读取企业 MDM 配置
startKeychainPrefetch() ~100-200ms 从 macOS Keychain 读取 API Key

如果等到需要用的时候再发起,用户在第一次 API 调用时会额外等待 200 ms。通过在 import 评估期间就发起,这 200 ms 和后续 1000 ms 的模块加载重叠了。

分阶段初始化:四阶段架构

main.tsx 的 4683 行可以分为清晰的四个阶段:

Phase 0:Side Effects (第 9-20 行)
	并行触发所有重量级 I/0
	   ↓
Phase 1:Module Setup (第 21-200 行)
	延迟 require、特性门控、条件导入
	   ↓
Phase 2: CLI Parsing (第 201-3700 行)
	Commander.js 解析参数
	配置解析、认证检查、权限加载 
	   ↓
Phase 3:UI Launch (第 3700+ 行)
	launchRepl() 渲染 TUI 
	或者进入 headless/sDK 模式

分阶段失败隔离:每个阶段失败都有意义

阶段 失败时的错误
Phase 0 几乎不可能:操作系统级别问题
Phase 1 "功能模块加载失败" + 模块名
Phase 2 "参数格式错误: --xxx 不存在"
Phase 3 TUI 初始化错误,fallback 到纯文本

Phase 2 失败时可以直接打印纯文本错误,不需要启动 TUI。

这比一个 main() 函数里 try-catch 所有错误要好得多,想象一下,如果所有错误都混在一起,就不好区分到底是参数错误还是 UI 初始化失败了;分阶段让每阶段的错误上下文清晰,用户的错误提示也更友好。

延迟 require:打破循环依赖

循环依赖问题

main.tsx -> 导入 tools -> 导入 AgentTool -> 导人 coordinator
		 -> 需要 main.tsx 里的状态 -> 循环!

coordinator 又需要 main.tsx 里的某些初始化状态,就导致循环了;Nodejs 和 Bun 遇到循环依赖时,被循环导入的模块可能是半初始化状态,导致 undefined 错误

解决方案:懒加载工厂函数

// ❌ 直接 import 会产生循环依赖
import { getTeammateUtils ) from './coordinator/teammateUtils'
// ✅ 延迟到需要时才 require
let getTeammateUtils: () => typeof import ('./coordinator/teammateUtils')
if (feature ('ENABLE AGENT SWARMS')) {
	getTeammateUtils = () => require ('./coordinator/teammateUtils')
}

() => require(...) 把模块加载推迟到函数被调用时。

Bun Feature 控 vs 传统 Feature Flag

传统 Feature Flag(运行时判断)

if (process.env.FEATURE X ==='true') {
	await import ('./enterprise/dashboard')
} 

❌ 代码永远在 bundle 里,只是运行时不执行

Bun Feature 门控(构建时消除)

if (feature ('FEATURE X')) {
	await import ('./enterprise/dashboard')
}

❌ 如果 FLAG=false,整个 if 块被删除,相关 import 也不打包

三个构建变体:同一代码库,多个 bundle

同一个代码库通过 feature 门控控制编译结果,无需维护三个代码分支

变体 开启的功能
OSS 基础工具集
Enterprise + COORDINATOR_MODE, + KAIROS
Internal (Ant) + 所有功能 + ANT_TOOLS

代价:

  1. 必须在构建时就确定哪些功能打开
  2. 无法动态开关
  3. 不同变体需要维护不同的构建配置

LazySchema:Zod 的延迟初始化

问题:60+工具的 schema 全部初始化

// ❌ 全部在模块顶层初始化
const agentInputSchema = z.object ({ description: z.string (), ... ))
const bashInputSchema = z.object ({ command: z.string (), ... })
// ... 60 个工具 

❌ 如果在模块加载时全部初始化,每个 schema 构建都有 cpu 开销,累计起来模块加载时间增加 ~50-100 ms

解决方案:惰性初始化

// ✅ 第一次使用时才构建
const inputSchema = lazySchema (() => {
	const base = z.object ({ command: z.string () })
	return feature ('BACKGROUND TASKS')
		? base.extend ({ run in background: z.boolean ().optional () })
		: base
})

只在第一次 checkPermissions() 调时才构建

好处:

  1. 启动速度更快(schema 只在需要时才构建)
  2. 支持在 schema 中使用 feature()(lazySchema 在调用时才执行,feature 会在构建时求值)
  3. 进一步打破循环依赖(懒加载时其他模块已经全部初始化完成)

启动性能的核心数字

性能打点标记

profileCheckpoint('main_tsx_entry') // 进入 main.tsx
profileCheckpoint('imports_complete') // 所有 import 完成
profileCheckpoint('cli_parsed') // 参数解析完成
profileCheckpoint('repl_ready') // TUI 渲染完成

已知的性能问题

问题 说明
模块加载 TypeScript + 大量 import,首次~500ms
Yoga Flexbox 加载 WASM 版本布局引擎,一次性开销
MCP 客户端 多个服务器并行连接,每个需要子进程或 HTTP

Bun 比 Node.js 快约 3x

优化手段:并行化与懒加载

并行初始化 MCP 客户端

// ✅ 不串行等待
const mcpClients = await Promise.all(
	configs.map(config => createMcpClient(config))
)

LazySchema

const inputSchema = lazySchema(() => z.object ({...}))

优化效果

优化项 节省时间
并行 I/O 启动 ~200ms
LazySchema ~50-100ms
Bun vs Node 3x 模块加载

仍有优化空间

问题1:Yoga WASM 阻塞

layout/yoga.ts 加载WASM文件是同步的
如果能改成异步(在 Phase0 并行触发),可以再节省 ~50ms。

问题2:MCP 客户端串行等待

失败的 MCP 服务器会影响整体初始化超时。
==改进:==用 Promise.allSettled + 独立超时,失败的 MCP 不阻塞正常启动。

问题3:首次运行没有缓存

startKeychainPrefetch() 只在 macOS 有效。Windows/Linux 用户没有 Keychain, 但仍执行空操作。


三、查询引擎与对话循环

核心签名

export async function* query (params: QueryParams):
	AsyncGenerator<StreamEvent I Message I TombstoneMessage, Terminal>

==异步生成器 ==-- 产出消息和事件,最终返回"终止原因“

两层查询架构

QueryEngine (QueryEngine.ts)
	|-- query () (query.ts)
职责 状态生命周期
query() 单轮对话的 API 调用 + 工具执行循环 单次调用
QueryEngine 跨轮次的消息历史、usage 统计、文件缓存 整个会话

类比:HTTP 会话 Vs HTTP 请求

QueryEngine = HTTP Session
	维护 cookie、历史
	生命周期:整个会话
query() = HTTP 请求
	可能有重试、重定向
	生命周期:一次请求

TUI 模式:直接调用 query(),历史由 TUI 层管理
SDK 模式:用 QueryEngine,维护跨次调用的状态

为什么是异步生成器 ?

方案对比:

❌ 回调函数
	问题:回调地狱,onComplete 何时调用不清晰
❌ EventEmitter
	问题:需要监听'end'事件,生命周期管理复杂
✅ 异步生成器(claude-code 的选择)
	for await(const event of query(params)) {...}
// 循环结束 = 查询结束

异步生成器的优势

// 双向协议:yield + return
for await(const event of query(params)) {
	// yield 产出中间状态
}
const terminal = /* 生成器的 return 值 */

为什么比 EventEmitter 更好?

不可变参数 vs 可变状态

// 不可变:整个循环期间不变
const { tools, maxTokens, systemPrompt, permissionMode ) = params

// 可变:每次迭代可能更新
type State = {
	messages: Message[]
	toolUseContext: ToolUseContext
	maxOutputTokensRecoveryCount: number
	turnCount: number
	transition: Continue | undefined 
	// ... 更多字段
}

为什么区分不可变和可变?

// 不直接修改,而是创建新 state
state = {
	...state, // 保留之前的所有字段
	messages: newMessages,
	turnCount: state.turnCount + 1,
	transition: { type: 'tool_use', toolsUse: ['BashTool'] }
}
continue queryLoop

transition 字段:记录了每次循环的原因,如果某次循环了 20 次,可以看到 transition 链中有为什么循环,在哪里循环

好处:

transition 字段:循环的"飞行记录"

Round 1: transition = { type: 'tool_use', tools: ['Read'] )
Round 2: transition = { type:'tool_use', tools: ['Edit'] }
Round 3: transition = { type:'max_output_tokens_recovery' } // 输出被API截断了
Round 4: transition = { type: 'tool_use', tools: ['Bash'] 1

七种 "继续循环"路径

type ContinueReason = 
	| 'tool use' // 模型请求调用工具
	| 'max_output_tokens_recovery' // API 截断,用更小 max tokens 重试
	| 'stop hook' // stop hook 注入新消息
	| 'reactive compact' // 触发上下文压缩
	| 'history snip' // SDK 模式下截断历史
	| 'context collapse' // 语义聚合旧消息
	| 'auto_background' // 任务转后台

max_output_tokens 恢复机制

API 返回 stop_reason:"max_tokens"
	↓
maxOutputTokensRecoveryCount < MAX RECOVERY ATTEMPTS?
	↓
YES -> 用更小的 max_tokens 重试
	↓
继续 queryLoop

为什么要恢复而不是直接报错?

工具调用结果本身是不完整的 JSON,直接报错会丢失上下文。

工具并发执行

// 三个只读工具:并行执行
toolCalls = [
	{ tool: GlobTool, args: {...} }, // isConcurrencySafe -> true
	{ tool: GrepTool, args: {...} }, // isConcurrencySafe -> true
	{ tool: ReadTool, args: {...} }, // isConcurrencySafe -> true
]
// -> Promise.all 并行执行(都返回 true 并行)

// 如果有写操作:整批串行(有一个返回 false)
EditTool.isConcurrencySafe () -> false // 写文件 
// -> 保守策略:只要有不安全的,全部串行

QueryEngine:跨轮次会话管理

export class QueryEngine {
	private config: QueryEngineConfig
	private mutableMessages: Message[] // 跨轮次积累取消整个会话 
	private abortController: AbortController // 取消整个会话
	private permissionDenials: SDKPermissionDenial[]
	private totalUsage: NonNullableUsage // 累计 token
	private readFileState: FileStateCache // 文件 LRU 缓存
}

FileStateCache:防止"过时编辑”

对话场景
1.用户:读取 main.py
2.Claude: 调用 Read("main.py")
3.用户:修改第 10 行
4.Claude:调用 Edit("main.py",...)

问题:Edit 执行前,如何知道"文件内容是否被外部修改"?

FileStateCache:存储上次读取的文件快照
	content:文件内容
	hash:内容哈希
	readAt:读取时间

FileStateCache 是一个 map,最多缓存 100 个文件的快照

LRU 淘汰策略

type FileStateCache = LRUMap<string, {
	content: string
	hash: string
	readAt: number
}>

默认最多缓存 100 个文件长对话中,超过 100 个文件快照的场景下,才用 LRU 策略

Stop Hooks:后采样拦截

Stop Hooks 可以在 API 响应后,Claude 给出最终结果之前,拦截并注入新消息

const stopHookResult = await runStopHooks ({
	messages: state.messages,
	assistantMessage: lastAssistantMessage
})

if (stopHookResult.shouldContinue) {
	state = {
		...state,
		messages: [...state.messages, ...stopHookResult.additionalMessages],
		stopHookActive:true,
		transition: { type: 'stop_hook' }
	}
	continue queryLoop
}

用途:

但是,它也是有风险的,因为 Stop Hooks 可以无限循环,hook 注入消息,Claude 响应,hook 在注入;所以,查询循环有最大 stopHookActive 计数保护


四、工具系统设计

Tool 接口全貌

export type Tool<Input, Output> = {
	// 身份
	name: string
	aliases?: string[]
	
	// 执行
	inputSchema: ZodSchema<Input>
	call (args, context, canUseTool, parentMessage) : Promise<ToolResult<Output>>
	
	// 权限
	checkPermissions (input, context) : Promise<PermissionResult>isReadOnly (input): boolean
	isDestructive (input): boolean
	
	// 并发
	isConcurrencysafe (input): boolean
	
	// 描述(送给 LLM)
	description (input, options): Promise<string>
	
	// TUI 渲染(送给用户)
	renderToolUseMessage (input, options)
	renderToolUseProgressMessagnse (progress, options)
	renderToolResultMessage(output, progress, options)
}

执行核心:inputSchema 的三层作用

Zod Schema 同时提供三层能力

const inputSchema = z.object ({
	file_path: z.string ()
		.describe('要编辑的文件绝对路径'),
	old string: z.string()
		.min(1,'替换目标不能为空')
		.describe('要替换的字符串,必须在文件中恰好出现一次')
	new_string: z.string()
		.describe(替换后的字符串')
	replace all: z.boolean ()
		.optional()
		.default (false)
		.describe('是否替换所有匹配项'),
})
层级 提供者 作用
TypeScript类型 z.infer 编译时类型检查
运行时验证 schema.parse(apiOutput) 校验LLM输出
参数描述 .describe() 注入API prompt

权限模型:声明与检查分离

两层权限设计

// 第一层:工具自身的权限声明
isReadonly (input): boolean // "我不写文件"
isDestructive (input): boolean // "我会删除/覆盖数据"

//第二层:checkPermissions()实现
async checkPermissions (input, context) : Promise<PermissionResult> {
	if (context.permissionMode === 'bypass') return { granted: true }
	const rule = findMatchingRule (context.toolPermissionContext, this.name, input)
	if (rule?.type=== 'allow') return { granted: true }
	if (rule?.type === 'deny') return { granted: false, reason: '...' }
	return { granted: false, requireUserApproval: true )
}

为什么要分开?

三层渲染系统:工具调用的 UX 设计

三个阶段对应三个渲染函数

Claude 决定调用工具
	↓
"renderToolUseMessage (input) <- "正在调用 Bash:1s -la /src"
	↓(工具执行中)
renderToolUseProgressMessage (progress) <- "已执行 3.2 s,输出 142 行..." 显示运行时长和实时输出行数
	↓(工具完成)
renderToolResultMessage (output,...) <- 结果(可折叠,语法高亮)

每个工具控制自己的呈现方式

工具组装管道:从实现到 Prompt

四步流程

//步骤 1:获取基础工具集
const baseTools = getAllBaseTools() // [AgentTool, BashTool, GlobTool, GrepTool,REPLTool(条件)] 返回所有内置工具

//步骤 2:按权限过滤
const filteredTools = filterToolsByDenyRules(baseTools, permissionContext) // 例:如果 deny_rules 包含"Bash",BashTool 被移除 -> 根据用户的拒绝规则过滤

//步骤 3:合并 MCP 工具 
const allTools = [
	...filteredTools.sort (byName), // 内置工具排序 
	...mcpTools.sort (byName) // MCP 工具排序(分开排序!)
]

//步骤 4:去重(内置工具优先)
const uniqueTools = unigBy (allTools, 'name')

为什么内置工具和 MCP 工具要分开排序?

Prompt Cache 的缓存稳定性问题

假设混合排序:
[AgentTool, BashTool, mcp_db_query, GlobTool, mcp_search_web, ...]

用户新增 mcp_auth_login 后:
[AgentTool, mcp_auth_login, BashTool, mcpdb_query, GlobTool,...] 
	↑插入到这里
整个列表顺序改变 -> prompt 缓存全部失效

分开排序后

内置工具前缀 (稳定):[AgentTool, BashTool, GlobTool, GrepTool,...]
MCP工具后缀 (变化):[mcp_auth_login, mcp_db_query, mcp_search_web ,...]

内置工具前缀不变 -> 前缀的 prompt cache 永远命中 -> 节省 token 费用

Tool Search:解决 60+工具的 Prompt 污染

问题

60 个工具全部注入 prompt -> 每次调用多消耗 ~5000-8000 token 大部分工具在一次对话中根本不会用到

解决方案:Tool Search 模式

// 默认只有 2 个工具:
[AgentTool, ToolSearchTool]
// ToolSearchTool 告诉 LLM:
"用关键词搜索可用工具。例如:search('readfile') 返回 [Read,FileRead]"

工作流程

用户:帮我修改 main.py 的第 10 行
	↓
Claude 调用:ToolSearchTool.search('edit file')
	↓
返回:[FileEditTool, StrReplaceTool, FileWriteTool]
	↓
Claude 调用:FileEditTool(path='main.py', old_string='...', new_string='...')

字段控制行为

AgentTool.alwaysLoadtrue // 始终加载,无论 ToolSearch 是否开启
BashTool.shouldDefertrue // 默认不加载,需要 ToolSearch
GlobTool.shouldDefer = true
WebFetchTool.shouldDefer = true

权衡分析

方面 全量加载 Tool Search
Token 消耗 高 (~5000-8000/次) 低 (按需加载)
API 延迟 多一轮搜索
容错性 低(依赖搜索质量)
适用场景 工具少 工具多

BashTool:安全分析的细节

语义分析:isSearchOrReadBashCommand

export function isSearchOrReadBashCommand (command: string):
	isSearch: boolean // grep、find、ls 等
	isRead: boolean // cat、 head、 tail 等
	isList: boolean // ls、tree 等
}

为什么分析管道语义?

# 命令: cat /etc/hosts | grep localhost
# 解析:两个阶段
# 1. cat (读文件) <- isRead = true
# 2. grep (搜索) <- isSearch = true
# 综合: isRead= true(只读命令)

# 命令: cat /etc/hosts > /tmp/output.txt
# 解析:
# 1. cat (读文件) <- isRead = true
# 2. > 重定向到文件 <- isRead = false!(写操作)
# 综合:isRead = false

五、权限模型与审批系统

场景引入

你让 Claude"清理项目临时文件"
Claude 决定执行: rm -rf /tmp/project_cache && rm -rf ./build

你想要什么行为?

四种权限模式

type PermissionMode = 
	| 'default' // 每次询问用户
	| 'auto' // 自动批准(基于分类器)
	| 'plan' // 必须先进入 Plan 模式
	| 'bypass' // 跳过所有权限检查
模式 适合场景 风险
default 日常使用,控制粒度高 高频打断
auto CI/CD 环境,无人值守 可能批准超预期操作
plan 复杂任务,先规划后执行 需额外 Plan/Exit 步骤
bypass 完全信任的本地环境 无任何安全护栏

权限决策链:多源解析顺序

checkPermissions (tool, input, context)
	|-- 1. 权限模式检查
	|		bypass? -> 直接批准 
	|		plan 模式且不在 plan 状态? -> 拒绝
	|-- 2. Deny Rules 检查(绝对否决)
	|		匹配到拒绝规则? -> 无条件拒绝
	|-- 3. Allow Rules 检查 
	|		匹配到允许规则? -> 批准
	|-- 4. 工具自定义逻辑
	|		tool.checkPermissions ()
	|-- 5. 弹出用户确认框
	|		Approve / Reject / Always Allow / Never Allow

关键:Deny Rules > Allow Rules > 工具自定义 > 用户交互

三个来源的规则

type PermissionSource = 'global' | 'user' | 'project'

级别 配置文件位置 适合场景 示例
Global 全局,~/.claude/settings.json 个人偏好,所有项目通用 例:永久允许 Read,永久禁止 Bash
Project 项目级,.claude/settings.json 项目特定规则,团队共享 例:允许 Bash (npm run test)
User 会话级,内存中,会话结束消失 本次会话临时授权

优先级:Deny Rules > Allow Rules,无论哪个层级,只要配置了 Deny 优先级就比 Allow 高

权限持久化:永久规则的代价

当用户点击"总是允许"时:

async persistPermissions (updates: PermissionUpdate []) {
	//1. 写入 settings.json(永久保存)
	persistPermissionUpdates (updates)
	//2. 更新内存中的 AppState(即时生效)
	const appState = toolUseContext.getAppState ()
	const newContext = applyPermissionUpdates (
		appState.toolPermissionContext,
		updates
	)
	setToolPermissionContext (newContext)
}

"总是允许 Bash" = 不可逆的权限升级

权限对话框:阻塞设计

权限确认是 claude-code 里唯一阻塞用户输入的场景:

工具调用触发权限检查
	↓
PermissionDialog 插入到 PromptInput 上方
	↓
主 REPL 输入框暂时禁用
	↓
用户选择:Approve / Reject / Always Allow / Never Allow
	↓
对话框消失,主输入框恢复
	↓
工具继续执行(或被拒绝)

为什么串行处理?

权限拒绝的会话记忆

//在 QueryEngine 里积累拒绝记录
private permissionDenials: SDKPermissionDenial[] = []

// 每次用户拒绝时追加
this.permissionDenials.push ({
	toolName: tool.name,
	input: JSON.stringify(args),
	deniedAt: Date.now(),
	userFeedback: dialog.feedback
)

这些记录的用途:

  1. 注入 LLM 上下文:告诉 Claude"用户曾经拒绝了 XXX”
  2. PatternDetection:同一工具被拒绝 3+ 次,触发解释流程
  3. SDK 透明性:调用方可以读取 denial 列表

MDM 策略:企业级强制锁定

对于企业用户,MDM 可以强制下发权限策略:

type MdmPolicy = {
	deniedTools: string[] // 强制禁用(不可覆盖)
	permissionMode: 'default' | 'auto' | 'bypass'
	allowedDomains?: string[] // WebFetch 允许的域名 
}

关键区别:

这是企业合规的基础:IT 管理员可以集中控制操作权限。

权限模型的设计权衡

需求 当前设计 潜在改进
安全性 DenyRules 绝对优先 增加操作审计日志
易用性 "总是允许"一键搞定 带过期时间的临时规则
细粒度 支持 glob 匹配 支持正则、范围、环境变量
企业合规 MDM 策略集中管控 RBAC 基于角色权限控制
透明度 权限对话框清晰 操作前预览影响

六、状态管理架构

React Context 在 Ink 里的问题

浏览器 React vs Ink React

环境 Context 更新行为 性能影响
浏览器 批量合并重渲染(微任务) 可接受
Ink 终端 重新计算整个布局(Yoga WASM) 可能闪烁

问题根源:

结果:Context 更新可能导致可见的终端闪烁

Store:三十行解决状态管理

export function createStore<T> (initialState: T): Store<T> {
	let state = initialState // 闭包变量保存状态
	const listeners = new Set<() => void>() // set存储所有监听器
	
	return {
		getState: () => state,
		setState: (updater) => {
			const next = updater (state)
			if (Object.is (next, state)) return
			state = next
			for (const listener of listeners) listener () // 遍历所有监听器通知状态改变
		},
		subscribe: (listener) => {
			listeners.add (listener) // 往 set 中添加监听器
			return () => listeners.delete (listener) // 返回取消订阅的函数
		}
	}
}

三个关键设计细节

  1. Object.is 浅相等检查 if (Object.is (next, state)) return
    强制调用方使用不可变更新。如果返回同一个引用,跳过通知。
  2. Set 而非数组 const listeners = new Set<() => void>()
    Set 自动去重,防止同一个 listener 注册两次导致重复触发。
  3. 返回取消订阅函数 subscribe: (listener) => { listeners.add(listener) return () => listeners.delete (listener) }
    组件卸载时调用,避免内存泄漏。

useSyncExternalStore:React 18 的桥梁

export function useAppState<T>(
	selector: (state: AppState) => T // selector 作用:从完整的 AppState 中选择你关心的字段
):T {
	return useSyncExternalStore (
		appStore.subscribe,
		() => selector (appStore.getState ()),
		() => selector (appStore.getState ()),
	)
}
	
// 使用示例
const model = useAppState (s => s.mainLoopModel)
const taskCount = useAppState (s => Object.keys (s.tasks).length)

useSyncExternalStore 会处理订阅逻辑:当 store 通知变化时,会调用 selector,然后用 Object.is 对比结果,如果结果没变,组件就不重新渲染

为什么这比 Context 好?

Context 的问题

AppState 变化 -> 所有消费 Context 的组件重渲染
-> 很多组件做无效渲染

useSyncExternalStore + selector 的优势:

AppState 变化 -> 调用所有 selector 
 -> 只有 selector 结果变化的组件重渲染
 -> 精确重渲染

性能对比: claude-code 有 50+个消费 AppState 的组件,每个 token 到来(高频!)都要更新状态;如果不用 selector: 每次 token 变化都会触发 50+ 次重渲染;用 selector: 每次可能只有 2-3 个组件重渲染

Deeplmmutable:编译时不可变保证

// 递归的讲所有字段加上 readonly,这样如果直接修改 state,ts 编译器就会直接报错
type DeepImmutable<T> = { 
	readonly [K in keyof T]: T[K] extends object
		? DeepImmutable<T[K]>
		: T[K]
}
		
// Appstate 定义
type AppState = DeepImmutable<{
	settings: SettingsJson
	mainLoopModel: ModelSetting
	toolPermissionContext: ToolPermissionContext
	mcp: { clients:...; tools:...; }
	// ...
}>

使用效果:

const state = appStore.getState ()

// TypeScript 编译错误!
state.settings.model = 'claude-3-haiku'
// 必须用不可变更新:
appStore.setState(prev => ({
  ...prev,
  settings: {
    ...prev.settings
  }
}))

Deeplmmutable 的局限性

TypeScript readonly 只是类型约束

//编译错误,TypeScript 层面阻止了
state.settings.model = 'xxx'
//但可以强制类型绕过,运行时不会报错
;(state as any).settings.model = 'xxx'

tasks 字段的例外

type AppState = DeepImmutable<{
	settings: SettingsJson
	tasks: { [taskId: string]: TaskState ) // 注意: 这里不是 DeepImmutable!
	// ...
}>

tasks 包含函数 task.kill()、task.getStatus(),TypeScript 的 Deeplmmutable 对包含函数的对象有限制,所以被显式排除。

消息队列:分离快变和慢变状态

claude-code 有两个主要的外部存储

Store 变化频率 内容 订阅者
AppState 慢变 用户设置、权限配置、MCP 连接、模型选择 50+ 个组件
MessageQueue 快变 用户输入的命令、等待处理的消息 仅 useQueueProcessor

为什么要分离?

如果把消息队列放进 AppState:每次用户按 Enter -> 触发所有订阅 AppState 的组件检查 selector -> 大多数组件的 selector 结果不变,但仍然执行了检查

分离后:消息队列变化只通知 useQueueProcessor,不影响其他组件

MessageQueue 实现

type Queuedcommand = {
	id: string
	priority: 'now' | 'next' | 'later'
	command: Command | string
	timestamp: number
}

const queue: QueuedCommand[] = []
const listeners = new Set<Listener>()

export function enqueue (cmd: Omit<QueuedCommand,'id' | 'timestamp'>) {
	queue.push ({ ...cmd, id: uuid (), timestamp: Date.now () })
	listeners.forEach (l => l())
}

export function subscribeToCommandQueue (listener: () => void) {
	listeners.add (listener)
	return () => listeners.delete (listener)
}

优先级处理:dequeue 时按 now > next > later 顺序取出

QueryGuard:互斥锁模式

防止同时执行多个 query 的机制

function createQueryGuard () {
	let isActive = false // 当前是否在执行 query
	const listeners = new Set< () => void>()
	
	return {
		// 加锁
		reserve (): boolean {
			if (isActive) return false
			isActive = true
			listeners.forEach (l => l())
			return true
		},
		// 释放锁
		release() {
			isActive = false
			listeners.forEach (l => l())
		},
		subscribe: (1) => {
			listeners.add(l)
			return () => listeners.delete (l)
		},
		getSnapshot: () => isActive,
	}
}

响应式流水线

用户输入 messageQueue.enqueue()
	↓(通知 useQueueProcessor)
检查 queryGuard.getSnapshot () -> false (空闲)
	↓
queryGuard.reserve () -> true (成功)
	↓
开始执行 query()
	↓(query 完成)
queryGuard.release ()
	↓(通知 useQueueProcessor)
检查 messageQueue -> 有新消息?
	|-- YES -> 立即处理下一条
	|-- NO -> 等待

useQueueProcessor 不需要知道"什么时候开始下一条",它只是响应状态变化。

状态快照与时间旅行调试

不可变状态的核心优势

由于 AppState 是不可变的,每次更新都产生新对象,可以把历史快照保存起来,理论上只要在 createStore 时监听每次更新,将新状态 push 到数组中,就可以保存完整的状态历史(cc 没实现,但是是支持的)

// 理论上可以这样实现时间旅行调试
const snapshots: AppState[] = []

createStore (initialState, ({ newState }) => {
	snapshots.push (newState)
})

// 回到历史状态
appStore.setState (() => snapshots[snapshots.length - 5])

设计一致性

每次节点执行产生新的 state 对象


七、Agent 工具与子 Agent 机制

claude-code 里最复杂的工具

==核心能力:==不执行操作,而是生成一个新的 Claude 实例来执行操作。

这是工具在调用工具 -- 一个 Claude 调用另一个 Claude。

AgentTool 的核心用途

场景:并行分析项目里所有 Python 文件的代码质量

// 主 Agent
for (const batch of chunks (pyFiles, 100)) {
	Agent({
		description: `分析 ${batch.length} 个 Python 文件`,
		prompt:分析以下文件的代码质量:${batch.join(',')),
		run_in_background: true
	})
}
// 10 个子 Agent 同时工作,速度提升 10x

核心价值:并行化 + 隔离

四种执行模式

模式 配置 适用场景
本地同步 默认 快速子任务(< 15 秒)
本地后台 run_in_background: true 耗时任务并行化
Fork Agent isolation: 'worktree' 文件系统隔离
Remote Agent isolation: 'remote' 长时间/特殊环境任务

从左到右:执行粒度越来越粗,隔离程度越来越高。

模式详解:本地同步 vs 本地后台

本地同步(默认):

主 Agent -> AgentTool.call() -> 子 Agent 执行 -> 返回结果 -> 主 Agent 继续

特点:简单直接,适合 < 15s 的快速任务。

本地后台 (run in background: true)

主 Agent -> AgentTool.call() 
	|-- registerAsyncAgent(taskId)
	|-- spawn 后台任务
	|-- 立即返回 { status: 'async_launched', agentId }
子 Agent 在后台运行 -> 写入 /tmp/claude_tasks/{taskId}.log

主 Agent 后续可用 Taskoutput({ task_id }) 检查进度。

15 秒自动后台化

场景:助手模式(KAIROS,通过消息应用发送任务)

主 Agent 调用 AgentTool (同步模式)
	↓
子 Agent 开始执行 1
	↓(15 秒后)
系统检测:任务还在运行 + 当前是助手模式
	↓
自动转后台:backgroundCurrentAgent()
	↓
返回:{ status: 'async_launched', message:'任务将在完成时通知你' }
	↓
用户可以关闭 whatsApp/Telegram

洞察:异步是正确的默认行为,同步是特殊需要。

Fork 子 Agent:隔离的代价和收益

工作原理

主 Agent (main 分支)
	|-- AgentTool.call({ isolation: 'worktree' })
		|-- 创建 gitworktree(新分支,独立目录)
		|-- Fork 文件状态缓存(快照,不共享)
		|-- 复制系统提示(共享 prompt cache)
		
子 Agent 在 worktree/branch-xxx 独立执行

收益:任务失败 -> 直接丢弃 worktree,主目录干净。

代价:

  1. 项目必须是 git 仓库
  2. worktree 创建有开销(~100 ms)
  3. 需要合并才能把修改应用到 main

Remote Agent:云端执行

适用场景:小时级别的长任务,或需要特定环境的任务

主 Agent(本地)
	|-- AgentTool.call({ isolation: 'remote' })
		|-- 检查 CCR (claude Cloud Run)可用性
		|-- 上传必要文件到 CCR 
		|-- 返回 { status: 'remote launched', sessionUrl }
子 Agent(在 CCR 云端运行)
	|-- 执行 prompt(拥有独立云端资源)
	|-- 结果可通过 sessionUrl 查看

特点:完全隔离的环境,不占用本地资源。

Agent Swarm:多 Agent 协作

创建具名队友

Agent({
	name: "alice',
	team_name: "backend_team",
	prompt:"你是后端架构师 Alice,负责 API 设计建议",
	mode:'auto'
})
SendMessage ({
	to: "alice",
	message:"Alice,帮我看一下这个 REST API 设计"
})

通信机制

SendMessageTool -> agentNameRegistry -> 消息队列 -> 目标 Agent 的 query() 循环

风险:多个 Teammate 同时修改同一文件 -> 竞争条件。

解决方案:每个 Teammate 使用独立 worktree。

Task 系统:统一的任务追踪

任务类型:

export type TaskType
	| 'local bash', // Bash 命令
	| 'local_agent', // 本地子 Agent
	| 'remote_agent', // 远程 Agent
	| 'in_process_teammate', // Swarm 队友
	| 'local_workflow' // 工作流

任务 ID 格式:

b_k2p9x7qm <- bash 任务
a_r4t8v2nj <- agent 任务
r_xly5z9ws <- remote 任务
t_m3n7k4gp <- teammate

设计要点:

任务 ID 的安全考量

问题:输出文件路径包含 ID(/tmp/claude_tasks/{taskId}.log)

攻击场景:符号链接攻击

攻击者在任务启动前创建符号链接:
/tmp/claude_tasks/a_r4t8v2nj.log -> /etc/passwd
主 Agent 写入输出文件时,实际写入了 /etc/passwd

防御措施:

  1. 足够长的随机 ID(8 位 base 36,2.8 万亿种可能)
  2. ID 在任务启动后才确定(攻击者无法预测)
  3. 文件写入前检查目标是否为符号链接

安全理念:不信任任何路径猜测,防御性编程。


八、MCP 集成与扩展架构

![[MCP核心架构.excalidraw]]

四种传输层

适用场景对比

传输层 通信方式 适用场景
Stdio 子进程 stdin/stdout 本地工具(最常见)
SSE HTTP 长连接 持久化服务器/云服务
StreamableHTTP HTTP 流式 需要流式响应的场景
WebSocket WebSocket 实时双向通信

选择原则:MCP 服务器在哪里,就用哪种传输层。本地命令行工具 > Stdio;远程 API > SSE/HTTP;测试调试 > InProcess

Stdio 传输详解

子进程通信模式

claude-code (主进程) 
	|-- 启动子进程:运行 mcp 服务器 npx @modelcontextprotocol/server-filesystem /path  
	|-- 写入子进程 stdin(JSON-RPC 请求)
	|-- 读取子进程 stdout(JSON-RPC 响应)

优点

SSE 传输详解

HTTP 长连接模式

claude-code (客户端)
	|-- HTTP GET https://mcp-server.example.com/events 
		(长连接,服务器推送事件)
推送消息时:
	<- event: message
		data: ("jsonrpc":"2.0","method":"tools/list","result":"xxx")

优点:

MCPTool 适配器

统一工具接口

function wrapMcpTool(serverName: string, mcpToolDef: MCPToolDefinition): Tool {
	const toolName = buildMcpToolName(serverName, mcpToolDef.name) // 例: mcp__filesystem_ read_file
	return { 
		name: toolName, 
		isMcp: true, 
		mcplnfo: { serverName, toolName: mcpToolDef.name },
		inputSchema: jsonSchemaTozod(mcpToolDef.inputSchema),
		async call(args, context) {
			// 1. 调用 MCP 服务器
			const result = await mcpClient. callTool({ name, arguments: args })
			// 2. 截断过大输出,大文件持久化到磁盘
			return truncateMcpContentIfNeeded(result)
		},
		async checkPermissions (input, context) {
			// 支持按服务器批量 deny
			const isDenied = context.toolPermissionContext.denyRules
				.some (rule => rule.startsWith(`mcp_$(serverName}`))
			if (isDenied) return { granted: false )
			return checkToolInDenyRules(this.name, context)
		}
	}
}

权限控制设计

deny rules 的威力

MCP 工具名称格式 mcp_serverName_toolName

"deniedTools": [
	"mcp_filesystem_*", // 禁用 filesystem 服务器所有工具
	"mcp_db_delete_*", // 只禁用 db 服务器的 delete 工具
	"mcp_*", // 禁用所有 MCP 工具
]

星号通配符 + 服务器名前缀
-> 实现从粗粒度(整个服务器)到细粒度(单个工具)的权限控制

资源系统

MCP 不只是工具

MCP 资源:可以被读取的数据端点,不只是本地文件

// 列出 MCP 服务器提供的资源
ListMcpResourcesTool.call({ serverName: 'filesystem' })
-> [
		{ uri: "file:///Users/tal/project/README.md" , name: "README", mimeType: "text/markdown" }, 
		{ uri:"file:///Users/tal/project/src/" , name: "src directory", mimeType: "inode/directory" }
   ]
// 读取具体资源
ReadMcpResourceTool.call({ serverName:'filesystem', uri: 'file:///Users/tal/project/README.md' })
-> "# Project\n\nThis is..."

资源 URI 可以是任意格式:

OAuth Token 自动刷新

认证服务器的处理

async call(args,context) {
	//执行工具调用
	try {
		return await mcpClient.callTool({ name, arguments: args })
	} catch (error) {
		//401 错误-尝试刷新 token
		if (error.code=== -32042 || error.message?.includes('401')) {
			const refreshed = await checkAndRefreshoAuthTokenIfNeeded(serverName)
			if (refreshed) {
				// 用新 token 重试
				return await mcpClient.callTool({ name, arguments: args })
			}
		}
		throw error
	}
}

Token 存储:通过 OneCLI(Agent Vault) 管理,不直接存在磁盘文件里

Elicitation

MCP 工具的交互模式

Claude 调用 mcp_github create_pr
	↓
GitHub MCP 服务器发现需要用户指定 base 分支
	↓
服务器返回 Elicitation 请求:
	{"elicitation":{"prompt":"请选择 PR 的目标分支","options":["main","dev"]}}
	↓
claude-code 显示选项给用户
	↓
用户选择"main"
	↓
claude-code 把选择发回 MCP 服务器
	↓
MCP 服务器完成 PR 创建

Elicitation 让 MCP 工具变成交互式的

Elicitation 的影响

打破"确定性工具"假设

传统工具:输入 > 输出(确定性,相同输入总是相同输出)

==有 Elicitation 的工具:==输入 -> 追问用户 -> 输出(不确定)

影响

  1. LLM 无法预知 Elicitation:在规划阶段,LLM 不知道这个工具调用会不会触发 Elicitation
  2. 不适合无人值守模式:有 Elicitation 的工具会卡住等待用户输入
  3. auto 权限模式的边界情况:如果遇到 Elicitation,应该有超时机制

结论:有 Elicitation 的 MCP 工具,在无人值守环境下需要谨慎使用

Prompt Cache 与 MCP

工具排序策略

用户配置了 3 个 MCP 服务器:A、B、C 
工具列表(按名称排序): 
	内置:[AgentTool,BashTool,...](稳定,命中缓存) 
	MCP:[mcp_A_tool1, mcp_A_tool2, mcp_B_tool, mcp_C_tool]
	
用户新增 MCP 服务器 D:
	内置:[不变](缓存命中)
	MCP:[mcp_A_tool1, mcp_A_tool2, mcp_B_tool, mcp_C_tool, mcp_D_tool]
																↑新增
MCP 部分变化 -> 前缀缓存失效(但内置工具缓存仍命中)

合理权衡:内置工具缓存最稳定,MCP 工具缓存次之

九、上下文压缩与记忆系统

问题:上下文窗口是有限的

Claude 的上下文窗口有限(目前约 200K token)。
一次长编程会话的 token 消耗:

系统提示				~2,000 token
工具列表				~5,000 token
CLAUDE.md 记忆		~1,000 token
对话+工具调用			每轮~3,000-10,000 token
---------------------------------------------------------------
第 10 轮				~58,000 token
第 20 轮				~108,000 token
第 25 轮				停止响应(too_long 错误)

没有压缩机制,长对话会:

  1. API 调用越来越贵(prompt cache 命中率下降)
  2. 推理质量下降(Claude 开始"忘记"早期内容)
  3. 最终触发 prompt_too_long 错误

三种压缩策略

策略 1:自动压缩(AutoCompact)

当 token 数量超过阈值时,在下一次 API 调用前自动触发:

if (calculateTokenWarning (tokenCount) >= COMPACT_THRESHOLD) {
	// 触发压缩:在后台执行,不中断当前对话 
	const compactResult - await triggerAutoCompact(state.messages)
	state = {
		..state,
		messages: compactResult.compactedMessages,
		transition: { type: 'reactive_compact' )
	}
	continue queryLoop // 继续原来的对话 
}

策略 2:响应式压缩(ReactiveCompact)

API 调用返回后,检测到 prompt_too_long 错误时触发,
区别:自动压缩是"预防性的",响应式是被动的(超限后再压缩并重试)

策略 3:手动压缩(/compact 命令)

压缩过程:Pre-compact Hooks

原始消息历史(100 条,150 Ktoken)
	↓
Pre-compact hooks(工具可注入"保留这个"上下文)
	↓
调用 Claude 生成摘要
	"本次会话主要做了:
	1. 创建了 user-service.ts, 实现了 CRUD 
	2. 修改了 auth.ts,添加了 JWT 验证
	注意:auth.ts 第 42 行的 TODO 还未完成"
	↓
CompactBoundaryMessage (标记压缩点)
	↓
Post-compact hooks(工具可注入"压缩后添加这个"上下文)
	↓
新消息历史(1 条摘要 + 最近 N 条原始消息,30K token)

压缩结果:150,000 token > 30,000 token(压缩 80%)

CompactBoundaryMessage:压缩点标记

压缩后的消息历史里,有一个特殊消息标记压缩点:

type SystemCompactBoundaryMessage = {
	role: 'system'
	type: 'compact boundary'
	compactedAt: number // 时间戳
	originalTokenCount: number //压缩前 token 数
	summary: string // 摘要内容
	retainedMessageCount:number //保留了几条原始消息
}

为什么需要这个标记?

  1. 调试:清楚看到"这里发生了压缩"
  2. 重播:如果需要重放对话,知道从哪里开始是压缩后的状态
  3. Tools:某些工具(如记忆工具)在压缩边界上注册 hook

压缩 Hook 系统

工具可以"干预"压缩过程:

Pre-compact:在压缩前检查有没有正在运行的项目,如果有,就把任务列表格式化后注入到上下文中。Claude 就不会失忆了

// Pre-compact hook:在生成摘要前注入内容 
type PreCompactHook = { 
	priority: number // 高优先级先执行 
	inject(messages: Message[]): string // 返回需要保留的上下文 
}

// Post-compact hook:在压缩后注入内容 
type PostCompactHook = {
	inject(summary: string): string // 修改或追加摘要 
}

实际应用---TaskTool 注册 pre-compact hook:

registerPreCompactHook({
	priority: 100,
	inject (messages) {
		const activeTasks = getActiveTasks ()
		if (!activeTasks.length) return
		return `[重要:以下后台任务正在运行 ${formatTasks (activeTasks)}]`
	}
})

CLAUDE.md 记忆与上下文压缩是互补关系

上下文压缩 CLAUDE.md
处理什么 当前会话的旧内容 持久知识、约束、规范
触发时机 自动(token 超限)或手动 启动时自动加载
信息损耗 有(摘要丢失细节) 无(全文保留)
适合存储 对话历史的摘要 不变的规范和约束

核心原则:

function findClaudeMdFiles (cwd: string) : string[] {
	const files = []
	//1.当前目录
	if (exists (`$(cwd)/CLAUDE.md`)) files.push(`${cwd)/CLAUDE.md')
	//2.父目录(向上遍历,直到 home 目录)
	let dir = cwd
	while (dir !== homeDir) {
		dir = path.dirname(dir)
		if (exists(`$(dir}/CLAUDE.md`)) files.push(`$(dir}/CLAUDE.md`)
	}
	//3.用户全局记忆
	if (exists(`~/.claude/CLAUDE.md`)) files.push(`~/.claude/CLAUDE.md`)
	return files
}

文件层次: ~/.claude/CLAUDE.md -> 项目根目录 -> 子目录

记忆附件机制

function createMemoryAttachment (claudeMdPath: string): Message {
	const content = readFile(claudeMdPath)
	return {
		role: 'user',
		content: [
			type: 'document',
			source_type: 'base64',
			media_type: 'text/plain',
			data: base64(content),
			cachecontrol: { type:'ephemeral') // 不缓存
		]
	}
}

为什么用 document 类型而不是 text?

Anthropic API 的 document 类型对文件内容有专门优化--Claude 会把它视为"参考文档"而不是“对话内容”,在处理时予更稳定的权重

嵌套记忆 (Nested Memory)

当 Claude 在对话中读取了某个子模块的 CLAUDE.md,系统会追踪这个文件路径:

private loadedNestedMemoryPaths = new Set<string>()

// 工具调用读取了 /some/project/CLAUDE.md
if (isClaudeMdFile(toolResult.path)) {
	if (!this.loadedNestedMemoryPaths.has(toolResult.path)) {
		this.loadedNestedMemoryPaths.add(toolResult.path)
		// 下一轮对话,把这个文件的内容作为记忆附件注入
	}
}

为什么?

Claude 读取了某个子模块的 CLAUDE.md,说明当前任务涉及那个模块。系统自动把该文件变成"持久附件",后续所有对话都会包含这个上下文。

Session 持久化与恢复

claude-code 把每次会话的消息历史保存到磁盘:

~/.claude/
|-- projects/
	|-- {projectHash}/
		|-- sessions/
			|-- (sessionId).json # 消息历史
			|-- {sessionId}.meta.json # 会话元数据

Resume 机制:

claude --resume # 继续最近的会话
claude --resume{sessionId} # 继续指定会话

Resume 时:

  1. 读取.jsonl 文件恢复消息历史
  2. 如果有压缩边界,从压缩点开始(不重播压缩前的内容)
  3. 重新连接 MCP 服务器
  4. 恢复文件状态缓存

压缩的"损失"问题

压缩不可避免地会丢失信息。主要丢失的内容:

  1. 工具调用的细节:摘要会说"修改了 X 文件",但不记得修改前的内容
  2. 被否决的方案:Claude 尝试了 A 方案被用户否决,摘要可能丢失这个信息
  3. 隐性约束:用户在对话中反复强调的偏好,压缩后可能被稀释

缓解措施

  1. 用户在 /compact 时提供"保留重点"指示
  2. Pre-compacthooks 允许工具注入"一定要保留的" 内容
  3. CLAUDE.md 可以手动记录重要约束==(这不会被压缩)==

实践建议

什么应该写进 CLAUDE.md?

❌ 正在讨论但未确定的内容
❌ 过期的信息(已经实现的 TODO)
❌ 不重要的临时偏好

核心原则:把 Claude 应该"永远记住的事情"写进去。


十、自动记忆系统与 Dream 模式

stopHooks 管道:每轮结束后的后台任务触发器

触发时机:每次 query 循环结束(模型产出最终响应,无工具调用时)

模型回复完成(无工具调用)
	↓
handleStopHooks() 触发:
	1.saveCacheSafeParams () <- 保存 prompt cache 快照
	2.executePromptSuggestion () <- fire-and-forget
	3.executeExtractMemories () <- fire-and-forget
	4.executeAutoDream() <- fire-and-forget
	5.executeStopHooks() <- 用户外部 hooks(阻塞)
	6.executeTeammateIdleHooks () <- Swarm 模式 teammate 钩子

为什么这些任务放在 stopHooks 里?

时机窗口:消息历史最完整 + prompt cache 刚更新 + 用户在等待

每轮 query 结束时:
|-- 消息历史是最完整的(包含了这轮的所有工具调用和结果)
|-- prompt cache 刚刚更新(forked agent 能直接共享这批 cache)
|-- 用户在等待下一次输入-有一个短暂的"空闲窗口"可以做后台工作

关键设计:fire-and-forget 不阻塞主线程,用户无感知。

extractMemories:每轮自动记忆提取

是什么:每次 query 结束后,在后台启动一个 forked:agent,分析这轮对话,把值得持久化的信息写入 memory/ 目录。

主对话结束
	↓
executeExtractMemories () (fire-and-forget)
	↓
runForkedAgent ('extract memories')
	|-- 共享父 agent 的 prompt cache 
	|-- 最多 5 轮(防止兔子洞)
	|-- 权限:只能写 memory 目录,Bash 只读
	|-- 完成后:在主对话 UI 显示 "SavedN memories"

只针对主 agent:subagent 跳过提取。

为什么用 forkedagent 而不是直接调用 API?

方案 A: 直接 API 调用 方案 B: forked agent (实际选择)
实现 简单 复杂
系统 prompt 需独立维护 复用父 agent
工具列表 需独立维护 复用父 agent
prompt cache 每次都要重新计算 自动命中父 agent 的缓存
权限系统 需独立实现 复用 canUseTool
成本节省:
如果工具描述占 5000 token,每次APl调用成本 $0.003/1K token 每天 100 次 extractMemories > 每天节省 $1.5 大型团队每天数千次调用,节省可观!

cursor 机制:不重复处理同样的消息

游标 lastMemoryMessageUuid:每次只处理游标之后的新消息

let lastMemoryMessageUuid: string | undefined
//每次只处理游标之后的新消息
const newMessageCount = countModelVisibleMessagesSince (
	messages,
	lastMemoryMessageUuid,
)
// 成功后推进游标
lastMemoryMessageUuid = messages.at(-1)?.uuid
第1轮:处理消息1-10,游标 -> 消息10的UUID
第2轮:处理消息11-15,游标 -> 消息15 的UUID
第3轮:处理消息16-20,游标 -> 消息20的UUID

容错设计:游标 UUID 被上下文压缩删除了?回退到"计算全部消息数量",而不是返回 0。

互斥检查与重叠保护

互斥检查:如果主 agent 在这轮已经直接写过 memory 文件,forked agent 跳过提取并推进游标。

if (hasMemoryWritesSince(messages, lastMemoryMessageUuid)) {
	return // 跳过,推进游标 
}

重叠保护:如果上一次提取还在运行,新的触发会把 context stash 起来,等当前提取完成后再运行一次 "trailing extraction"。

触发 1 -> 开始运行
触发 2 -> stash pendingContext 
触发 1 完成 -> 发现 pendingContext -> 运行 trailing extraction

autoDream:后台记忆整合系统

设计意图:

extractMemories autoDream
事后追加 定期整合
每轮写几条新记忆 把碎片整合成结构化的、去重的、更新的记忆库
类比:每天记日记 类比:每月整理笔记

问题积累:相互矛盾的事实("上周用 Jest","这周改 Vitest")-过时的信息("TODO:修 auth bug"但 bug 已修好) - 重复的描述(三个地方都说"不用 async/await")

三关门系统:成本从低到高

关门 1(时间关):距上次整合 ≥ 24 小时?
	-> 1 次文件 stat 操作,成本极低
	-> 不满足:直接返回,99% 的情况在这里结束
关门 2(会话关):自上次整合后,有 ≥ 5 个新会话?
	-> 扫描 transcript 目录(有 10 分钟节流)
	-> 不满足:跳过
关门 3(锁关):没有其他进程正在整合?
	-> 分布式文件锁(修改 mtime 实现)
	-> 已锁定:跳过

为什么按这个顺序?

最贵的操作放最后。时间检查只需要一次 stat。会话扫描需要遍历目录。锁操作需要文件写入。

Dream 的四阶段整合 prompt

Dream 以 forked agent 形式运行,prompt 规定了明确的四阶段工作流:

Phase 1 - Orient(定向)
	1s 记忆目录,读 MEMORY.md 索引
	浏览现有主题文件,了解已有内容
Phase 2 - Gather recent signal (收集新信号)
	优先级:日志文件 > 有漂移的现有记忆 > transcript 搜索
	注意:不要大量读 JSONL,只针对性 grep
Phase 3 - Consolidate(整合) 
	合并新信号到现有主题文件,不创建重复
	相对日期转绝对日期("yesterday" -> "2026-04-20")
	删除与当前状态矛盾的旧事实
Phase 4 - Prune and index(修剪和索引) 
	更新 MEMORY.md,保持 ≤ 25KB、每条 ≤ 150 字符
	移除过时条目
	解决两个文件之间的矛盾

DreamTask:Dream 进度可见性

Dream 运行期间,用户可以在后台任务对话框看到进度(Shift + Down):

export type DreamPhase = 'starting' | 'updating'
export type DreamTaskState = {
	type:'dream'
	phase: DreamPhase // 第一次 Edit/Write 时 starting updating
	sessionsReviewing: number
	filesTouched: string[] // 已修改的记忆文件
	turns: DreamTurn[] // 每个 assistant turn 的摘要
	priorMtime: number // 用于 kil 时回滚锁
}

phase 变化:DreamProgressWatcher 监听 forked agent 的每条消息。当检测到 Edit 或 Write 工具调用时,phase 从'starting' 变为 'updating'。

Kill 和锁回滚

用户可以从后台任务对话框里终止 Dream。Kill 流程:

用户点击 Kill
	↓
DreamTask.kill()
	↓
abortController.abort() <- 中止 forked agent
	↓
rollbackConsolidationLock(priorMtime) - 把文件 mtime 回滚到整合前
	↓
结果:下次会话可以重新触发 Dream

为什么必须回滚?

如果不回滚锁,锁文件的 mtime 指向一个"未完成的整合"。
时间关门会认为整合刚完成,24 小时内不会再触发。
被 kill 的 Dream 白白浪费了一次整合机会。

功能门控:谁不能 Dream?

Dream 模式被多重门控

function isGateOpen(): boolean {
	if (getKairosActive()) return false // 消息助手模式不运行 
	if (getIsRemoteMode()) return false // 远程模式不运行 
	if (!isAutoMemoryEnabled()) return false
	return isAutoDreamEnabled()

GrowthBook 旗标: 'tengu_onyx_plover'
控制 minHours 和 minSessions 两个参数的远程调整。
bare 模式豁免:-p 非交互模式下,stopHooks 的步骤 2-4 全部跳过。

awaySummary:「你离开期间」摘要卡

当用户离开一段时间后返回,claude-code 会显示一个"期间摘要"卡片,用 1-3 句话告诉用户:当前在做什么 + 下一步是什么。

const RECENT_MESSAGE_WINDOW = 30 // //只看最近 30 条消息 
async function generateAwaySummary (message,signal) {
	const memory = await getSessionMemoryContent() // session memory 作上下文 
	const recent = messages.slice(-RECENT_MESSAGE_WINDOW) 
	recent.push (createUserMessage({ content: buildAwaySummaryPrompt(memory) }))
	return await queryModelWithoutStreaming({
		model: getSmallFastModel() // 小模型:快速 + 便宜
		skipCacheWrite: true // 一次性调用,不写 cache
	}),
}

runForkedAgent:共享底层基础设施

extractMemories、autoDream、awaySummary 都依赖 runForkedAgent

export async function runForkedAgent ({
	promptMessages, // 父 agent 的消息历史
	cacheSafeParams, // 来自 saveCacheSafeParams,包含父 agent 的 cache key
	canUseTool, // 权限控制函数
	querysource, // 用于 analytics
	forkLabel, // 用于调试日志
	skipTranscript, // forked agent 不写 transcript (避免竞争)
	maxTurns, // extractMemories 限制 5 轮
	overrides, // 可传入 abortcontroller
	onMessage // 消息流回调(dream 用于追踪进度)
}): Promise<ForkedAgentResult>

关键:forked agent 的工具列表必须和父 agent 完全相同,才能命中 prompt cache。

三个系统的分工

extractMemories autoDream awaySummary
触发时机 每轮对话结束 24 h + 5 会话后 用户返回时
目标 追加新信息 整合现有信息 提供即时上下文
方向 增量写入 重组+去重+删除 不写,只读
类比 每天记日记 每月整理笔记 便利贴
成本 中(forked agent) 高(forked agent × 多会话) 低(小模型,1 次调用)

三者互补:extractMemories 保证捕获,autoDream 保证质量,awaySummary 保证可用性。

设计分析:为什么是forked agent

核心优势:

trade-off:

成本分析:
如果工具描述占 5000 token,每次 API 调用成本 $0.003 / 1Ktoken 每天 100 次 extractMemories 调用 > 每天节省 $1.5 大型团队每天数千次调用,节省可观!

设计分析:三关门顺序的重要性

如果关门顺序反过来(先锁,再会话,再时间):

正确顺序(先时间,再会话,再锁):

时间复杂度:

错误顺序:O(锁检查+会话扫描+ 时间检查) ≈ O(n)
正确顺序:O(时间检查) ≈ O(1) // 99%的情况

设计分析:extractMemories vs autoDream

extractMemories autoDream
触发频率 每轮 24 小时 + 5会话
目标 追加新信息 整合现有信息
方向 增量写入 重组 + 去重 + 删除
类比 每天记日记 每月整理笔记

两者的互补性:

==没有 Dream 会怎样? ==